AI 回的是一篇文章,但程式要的是一個可以 JSON.parse() 的物件
LLM 本質上是「接龍文字」,不是「填表單」
LLM 是逐字生成下一個最可能的字,本質上是個「接龍文字」的模型。就算 prompt 裡明講「只回 JSON,不要其他文字」,它在語意上理解了這個要求,但生成過程沒有強制保證輸出百分之百是合法的 JSON 格式——實務上還是可能在前後多包一些不屬於 JSON 的字元。這也是為什麼「Structured Output」(結構化輸出)會被特別拉出來當一個要解決的問題:AI 的輸出跟程式碼能安全解析的資料之間,中間需要一層處理。
先弄清楚:我們到底在解決什麼?
AI Diet Copilot 的流程是:使用者寫一段自由文字 → AI 分析 → AI 生成菜單 → 前端畫面顯示。這中間 AI 的每一次回覆,都是要交給程式碼繼續處理的,不是給人看的,所以 AI 的回覆必須是程式讀得懂的固定格式,而不是一篇順順的文章。
「AI 的輸出能不能直接拿來用」其實有兩層問題:
我們是怎麼做的:四個環節接力
① 在 prompt 裡直接寫死 JSON 結構
不是只說「請回 JSON」,而是把要的欄位、型態整份貼進 prompt,讓 AI 照著填空。以呼叫 #1(飲食型態分析)為例:
{
"mealPattern": {
"mealsPerDay": number,
"skipsBreakfast": boolean,
"hasLateNightSnack": boolean
},
"preferences": {
"spicy": boolean,
"likedFoods": string[]
}
}
(此為節錄,完整版有更多欄位,完整版請參考Day17)prompt 裡還寫了「請嚴格依照以下 JSON 格式輸出,不要輸出任何 JSON 以外的文字」。這能大幅提高 AI 聽話的機率,但只是「請求」,不是「保證」。)
② 用共用函式 invokeClaudeForJson() 接住 AI 的回覆
程式不直接信任 AI 的原始回覆,而是統一走同一條流程:送 prompt → 取出回覆文字 → 剝掉可能多出來的 code fence(下面「踩雷」會細講)→ JSON.parse()。
③ 用 TypeScript 型別告訴程式「這包資料長什麼樣」
問題:JSON.parse() 回來的東西,程式不知道裡面有什麼。 它的結果對編譯器來說是「任何東西都有可能」,像一個沒貼標籤的箱子。此時如果寫:
const data = JSON.parse(text);
data.mealPattern.skipBreakfast
// 這裡的skipBreakfast少打了一個 s,應該要是skip"s"Breakfast
// 編輯器不會提醒,要到執行時才發現是 undefined
解法:先定義型別,再幫資料貼標籤。 在專案裡先用 TypeScript 的 interface 定義「飲食型態分析」應該長什麼樣子(即 prompt 裡要求 AI 填的那份 JSON 結構):
interface DietaryAnalysis {
mealPattern: {
mealsPerDay: number;
skipsBreakfast: boolean;
hasLateNightSnack: boolean;
};
preferences: {
spicy: boolean;
likedFoods: string[];
};
}
(此為示範而已,完整版有更多欄位)然後在呼叫端用 as 幫回傳的資料「貼標籤」:
// 用 DietaryAnalysis 來定義型別
export async function analyzeDietaryFreeText(freeText: string, lifestyle: Lifestyle): Promise<DietaryAnalysis> {
const prompt = buildAnalysisPrompt(freeText, lifestyle);
return (await invokeClaudeForJson(prompt)) as DietaryAnalysis;
}
as DietaryAnalysis 就是在說:「這個箱子裡的東西,我保證它是 DietaryAnalysis 的形狀」。貼完標籤後有兩個好處:
analysis.mealPattern. 就會跳出欄位提示,不用背欄位名skipBreakfast 編輯器立刻標紅線,在執行前就能抓到但標籤只是「聲明」,不是「檢查」。 as DietaryAnalysis 只是告訴編譯器「相信我,它是這個形狀」,程式實際執行時並不會真的檢查 AI 有沒有照做。如果 AI 實際回的是:
{ "mealPattern": { "mealsPerDay": "三餐" } }
(數字變成字串、skipsBreakfast 也漏掉)編譯器照樣放行,錯誤要等到後面用到時才爆,或默默算出錯誤的結果。
可以想成在箱子外面貼「內容物:蘋果」的標籤:標籤讓拿東西方便,但不代表箱子裡真的是蘋果,要打開檢查才知道。所以格式之外的可信度,還需要下一個環節。
④ 不完全信任內容,另外設計驗證機制
格式合法不代表內容正確,所以另外做了幾層保護,例如:菜單每餐的份數加總後,要跟程式算出的當日目標比對,落差太大就重打一次;AI 選了哪些食物只讓它回編號,營養數字由程式回資料庫查(下面「設計取捨」會講)。
一句話總結這四個環節:prompt 負責「請 AI 照格式回」,invokeClaudeForJson() 負責「把回覆安全變成資料」,型別負責「讓後面的程式好寫」,驗證機制負責「不讓錯誤的內容一路流到畫面上」。
踩雷:Haiku 4.5 把 JSON 包了一層 code fence
即使 prompt 明確要求「只回 JSON」,實測 Haiku 4.5 還是會在回應前後加上一層 Markdown 程式碼區塊標記(三個反引號加 json、結尾再一次三個反引號),這就是所謂的 code fence。直接拿去 JSON.parse() 會失敗,因為前後多了不屬於合法 JSON 的字元。
為什麼 AI 會這樣?
**為什麼值得特別記錄?**因為它很隱蔽:prompt 沒寫錯、AI 的回覆內容也完全正確,只是前後多了幾個字元就讓程式整個失敗;而且不是每次都發生(這次包了下次可能沒包),也是第一次串 LLM API 最常見的坑之一。
我以為拿到的(乾淨 JSON):
{ "meals": [ { "meal": "午餐", "suggestion": "..." } ] }
實際拿到的(多了前後兩行):
(三個反引號)json
{ "meals": [ { "meal": "午餐", "suggestion": "..." } ] }
(三個反引號)
SyntaxError: Unexpected token '`' ... is not valid JSON
解法是在解析前先剝除這層 code fence,再丟給 JSON.parse()。核心邏輯只有兩行(正則直接寫在 match() 裡):
const codeFenceMatch = rawText.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
const jsonText = codeFenceMatch ? codeFenceMatch[1] : rawText;
真正的正則表達式就是 /.../ 之間的這一串。我們把它拆開來看:
| 片段 | 白話 |
|---|---|
^ |
從文字最開頭開始比對 |
| 三個反引號 | code fence 的開頭標記 |
(?:json)? |
後面可以接 json,也可以沒有(? 代表「有或沒有」) |
\s* |
吃掉中間可能的空白與換行 |
([\s\S]*?) |
拿來「抓出中間內容」:[\s\S] 是任何字元(含換行),*? 是盡量少抓;外面的括號代表「把這段記下來」 |
\s* |
吃掉結尾前的空白與換行 |
三個反引號 + $ |
結尾也必須是三個反引號 |
白話講就是:「如果整段文字是『開頭三個反引號(可能接 json)、中間任意內容、結尾三個反引號』,就把中間那段抓出來」。比對成功時,codeFenceMatch[1] 就是括號抓到的中間內容(乾淨的 JSON);比對不到(代表 AI 這次沒包 code fence)就原樣使用 rawText。
完整的 invokeClaudeForJson() 就是把上面這一段放進一條完整的流程:
async function invokeClaudeForJson(prompt: string, maxTokens = 1024): Promise<unknown> {
// 1. 送出 prompt 給 Bedrock 上的 Claude
const command = new InvokeModelCommand({
modelId: MODEL_ID,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: maxTokens,
messages: [{ role: 'user', content: prompt }],
}),
});
const response = await client.send(command);
// 2. Bedrock 回傳的 body 是位元組,先解碼成字串、再 parse 成物件
const responseBody = JSON.parse(new TextDecoder().decode(response.body));
// 3. Claude 的回覆文字在 content[0].text
const rawText: string = responseBody.content[0].text;
// 4. 剔掉可能多出來的 code fence(沒包就原樣使用)
const codeFenceMatch = rawText.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
const jsonText = codeFenceMatch ? codeFenceMatch[1] : rawText;
// 5. 真正的 JSON.parse;失敗時先把 AI 的原始回覆印出來方便事後查問題,再把錯誤丟出去
try {
return JSON.parse(jsonText);
} catch (err) {
console.log('Failed to parse Bedrock response as JSON:', rawText);
throw err;
}
}
幾個細節:第 2 步的 JSON.parse 是在解 Bedrock 的「外層信封」,第 5 步才是在解 AI 回的「裡面的信」,兩次不是同一件事;回傳型別寫 Promise<unknown>,意思是「這包資料目前長什麼樣還不知道」,由呼叫端自己負責標記型別(見前面的③)。
設計取捨:用 foodId 引用,不是整包塞資料
先講問題:菜單生成(呼叫 #2)要 AI 推薦「這餐吃什麼」。最直覺的做法是請 AI 連營養資料一起寫出來,例如「7-11 鮪魚飯糰,熱量 180 大卡、全穀雜糧 1.5 份……」。但 AI 是憑印象在「接龍」,這些數字可能寫得很像真的,其實是錯的,而且每次還可能不一樣。
後來用的做法像點餐:不讓 AI 背菜單,而是把「候選食物清單」(來自 Food DB)貼進 prompt,每個品項都有一個編號 foodId,請 AI 只要回報「我選了哪幾號」。
送給 AI 的候選清單(示意):
[
{ "foodId": "711-001", "brand": "7-11", "name": "鮪魚飯糰", "servingSize": "1 個" },
{ "foodId": "fm-042", "brand": "全家", "name": "無糖豆漿", "servingSize": "400ml" }
]
AI 回傳的 JSON(示意,只有編號,沒有營養數字):
{
"meal": "午餐",
"suggestion": "7-11 鮪魚飯糰搭配全家無糖豆漿",
"referencedFoodIds": ["711-001", "fm-042"]
}
程式拿到編號後,回頭去 Food DB 查出這兩樣食物真正的營養資料,再由程式精確算出這一餐的六大類份數。整個流程是:
referencedFoodIds
這樣做的好處:
補充:如果候選清單是空的、或都不適合,AI 仍會自己估一份份數當備案(referencedFoodIds 留空陣列),只有「有選到真實食物」的餐次,才會被程式算出的數字覆蓋。
Structured Output 不是「跟 AI 說清楚格式」就結束了,還要在「AI 輸出」跟「程式碼使用」之間留一層防禦性的解析/驗證邏輯,而且更根本的做法是——透過設計,讓 AI 沒有機會、也沒有必要去覆述本來就該由程式碼掌握的精確資料。就算格式穩了,還有一個更根本的問題沒解決:AI 講得有條有理,不代表它是對的——這是下一篇要討論的事。